DSG Projekt T5
  • Home
  • Projektcharta
  • Datenbericht
  • Modellierungsbericht
  • Evaluationsbericht

Auf dieser Seite

  • Rohdaten
    • Übersichtstabelle der Rohdatensätze
    • Details Spotify Songs (Kaggle & API)
      • Datenkatalog
      • Datenqualität
    • Details Genius Lyrics
      • Datenkatalog
      • Datenqualität
  • Prozessierte Daten
    • Übersichtstabelle der Prozessierten Daten
    • Details Finale Datasets
      • Verworfene Zeilen
      • Feature Engineering
      • Datasets joinen
      • Songtext Verarbeitung
      • Datenkatalog
      • Datenqualität

Datenbericht

Autor:in

Christian Bosshard, Enea D. Fedel und Gopigan Villavarayasingam

Veröffentlichungsdatum

21. Dezember 2025

Rohdaten

Übersichtstabelle der Rohdatensätze

Datensatz Name Quelle Speicherort
Spotify 1.2M+ Songs Kaggle data/tracks_features.csv
Spotify API (GET /tracks) Spotify API data/raw_responses/batch_{i}.json
Genius Song Lyrics Kaggle data/song_lyrics.csv

Details Spotify Songs (Kaggle & API)

Die Grundbasis dieser Arbeit ist das “Spotify 1.2M+ Songs” Dataset von Rodolfo Figueroa auf kaggle.com. Enthalten ist eine CSV-Datei, tracks_features.csv, welche Daten zu über 1.2 Millionen Songs auf Spotify von der Spotify Developer API enthalten. Spezifisch von den /tracks und /audio-features endpoints.

Audio Features sind Informationen über ein Lied, welche zum einten direkt dem Lied entnommen wurden, wie z.B. duration_ms, tempo und key, sowohl auch Werte welche durch eine Analyse von Spotify kalkuliert wurden (z.B. speechiness, valence und energy).

Leider fehlt in diesem Dataset eine wichtige Kennzahl, popularity. Deswegen haben wir diese Zahlen selber anhand den Songs im Dataset von der API entnommen.

Mithilfe einem API Account und dem folgenden Python Skript haben wir dies erreicht. Es..

  1. liest alle Spotify Track IDs des tracks_features.csv ein
  2. fragt den API endpoint tracks in Batches von 50 Tracks ab
  3. speichert die Responses pro Batch im directory data/raw_responses als batch_{i}.json ab.
Code
import json
import math
import os
import time
from pathlib import Path

import pandas as pd
import requests as rq
from dotenv import load_dotenv
from tqdm import tqdm

load_dotenv()

CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
OUTPUT_DIR = Path("data/raw_responses")
BATCH_SIZE = 50


def get_access_token() -> str:
    auth_url = "https://accounts.spotify.com/api/token"
    try:
        auth_response = rq.post(
            auth_url, {"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}
        )
        auth_response.raise_for_status()
        return auth_response.json()["access_token"]
    except Exception as e:
        print(f"Failed to get access token: {e}")
        raise


def main() -> None:
    df = pd.read_csv("data/tracks_features.csv")
    track_ids = df["id"].dropna().unique().tolist()

    total_tracks = len(track_ids)
    print(f"Found {total_tracks} tracks.")

    token = get_access_token()
    headers = {"Authorization": f"Bearer {token}"}

    num_batches = math.ceil(total_tracks / BATCH_SIZE)

    for i in tqdm(range(num_batches), desc="Fetching batches"):
        batch_file = OUTPUT_DIR / f"batch_{i}.json"

        # Skip if already processed
        if batch_file.exists():
            continue

        start_idx = i * BATCH_SIZE
        end_idx = start_idx + BATCH_SIZE
        batch_ids = track_ids[start_idx:end_idx]

        ids_param = ",".join(batch_ids)
        url = f"https://api.spotify.com/v1/tracks?ids={ids_param}"

        while True:
            try:
                time.sleep(2)
                response = rq.get(url, headers=headers)

                if response.status_code == 200:
                    data = response.json()
                    with batch_file.open("w") as f:
                        json.dump(data, f)
                    break

                if response.status_code == 429:
                    retry_after = int(response.headers.get("Retry-After", 5))
                    tqdm.write(f"Rate limited. Sleeping for {retry_after} seconds.")
                    time.sleep(retry_after + 1)

                elif response.status_code == 401:
                    tqdm.write("Token expired. Refreshing...")
                    token = get_access_token()
                    headers = {"Authorization": f"Bearer {token}"}

                else:
                    tqdm.write(f"Error {response.status_code} for batch {i}: {response.text}")
                    # Log error and skip this batch to avoid blocking
                    error_file = OUTPUT_DIR / f"error_batch_{i}.txt"
                    with error_file.open("w") as f:
                        f.write(f"Status: {response.status_code}\n{response.text}")
                    break

            except rq.exceptions.RequestException as e:
                tqdm.write(f"Request exception: {e}")
                time.sleep(5)


if __name__ == "__main__":
    main()
Error reading CSV: [Errno 2] No such file or directory: 'data/tracks_features.csv'

Danach hatten wir 24’081 JSON files, die wir mit dem tracks_features.csv Dataset joinen mussten. Dies haben wir mit dem folgenden Skript vollendet. Aus Performance Gründen haben wir das orjson Python package und eingebautes Multiprocessing verwendet:

Code
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import orjson

# load all json files in data/raw_responses and combine into a single dataframe
data_path = Path("data/raw_responses")
json_files = list(data_path.glob("*.json"))
print(f"Found {len(json_files)} JSON files.")


def process_file(file_path: Path) -> list:
    data = orjson.loads(file_path.open("rb").read())
    tracks = []
    for t in data.get("tracks", []):
        t.pop("available_markets", None)
        tracks.append(t)
    return tracks


print("Processing files in parallel...")
tracks_all = []
with ThreadPoolExecutor() as executor:
    results = executor.map(process_file, json_files)

    for tracks in tqdm(results, total=len(json_files)):
        tracks_all.extend(tracks)

df = pd.DataFrame(tracks_all)
print(f"Total tracks loaded: {len(df)}")
Found 0 JSON files.
Processing files in parallel...
0it [00:00, ?it/s]
Total tracks loaded: 0
Code
df_features = pd.read_csv("data/tracks_features.csv", keep_default_na=False)
print(f"Tracks geladen: {len(df_features)}")

df_combined = df_features.merge(df[["id", "popularity"]], on="id", how="left")
print(f"Kombinierte shape: {df_combined.shape}")

# How many rows with missing popularity?
missing_popularity = df_combined["popularity"].isna().sum()
print(f"Zeilen mit fehlender popularity: {missing_popularity}")

# Export to CSV
output_path = Path("data/full_tracks_features.csv")
df_combined.to_csv(output_path, index=False)

KeyboardInterrupt

Nun hatten wir die popularity Metrik für alle 1’204’025 Tracks hinzugefügt und als eine neue Datei full_tracks_features.csv abgepeichert.

Datenkatalog

Note: Add references to API docs

Index Name Datentyp Werte Beschreibung
1 id string Format: base-62 Die Spotify-ID für den Track
2 name string - Der Name des Tracks
3 album string - Das Album, auf dem der Track erscheint.
4 album_id string Format: base-62 Die Spotify-ID für das Album
5 artists Liste von strings - Die Künstler, die den Track performt haben.
6 artist_id Liste von strings Format: base-62 Die Spotify-ID für die Künstler
7 track_number integer Wertebereich: 1 - 50 Die Nummer des Tracks auf dem Album.
8 disc_number integer Wertebereich: 1 - 13 Die Disc-Nummer, auf dem der Track erscheint
9 explicit boolean true = Ja
false = Nein oder unbekannt
Ob der Track explizite Texte enthält
10 danceability float Wertebereich: 0 - 1 Tanzbarkeit beschreibt, wie geeignet ein Track zum Tanzen ist, basierend auf einer Kombination musikalischer Elemente.
0.0 → am wenigsten tanzbar, 1.0 → am tanzbarsten
11 energy float Wertebereich: 0 - 1 Wahrnehmungsmass für Intensität und Aktivität dar, typischerweise fühlen sich energiegeladene Tracks schnell, laut und geräuschvoll an
12 key integer Wertebereich: -1 - 11 Die Tonart, in der sich der Track befindet, basierend auf Standard-Pitch-Class-Notation, Wert -1 = keine Tonart erkannt
13 loudness float Wertebereich: -60 - 0
Einheit: Dezibel (dB)
Die Gesamtlautstärke eines Tracks in Dezibel (dB)
14 mode integer 1 = Major, 0 = Minor Gibt die Tonalität (Dur oder Moll) eines Tracks an
15 speechiness float Wertebereich: 0 - 1 Erkennt das Vorhandensein von gesprochenen Worten in einem Track
16 acousticness float Wertebereich: 0 - 1 Konfidenzmass ob der Track akustisch ist
17 instrumentalness float Wertebereich: 0 - 1 Sagt voraus, ob ein Track keinen Gesang enthält
18 liveness float Wertebereich: 0 - 1 Erkennt die Anwesenheit eines Publikums in der Aufnahme
19 valence float Wertebereich: 0 - 1 Beschreibt die musikalische Positivität, die von einem Track vermittelt wird
20 tempo float Einheit: beats per minute (BPM) Das geschätzte Gesamttempo eines Tracks in Schlägen pro Minute (BPM)
21 duration_ms float Einheit: Milisekunden (ms) Die Dauer des Tracks in Millisekunden.
22 time_signature integer Wertebereich: 3 - 7 Eine geschätzte Taktart, gibt wie viele Schläge in jedem Takt enthalten sind
23 year integer Format: YYYY Das Jahr des Veröffentlichungsdatums des Tracks
24 release_date string Formate:
YYYY,
YYYY-MM,
YYYY-MM-DD
Das Datum, an dem das Album erstmals veröffentlicht wurde. Präzision und somit Format variiert
25 popularity integer Wertebereich: 0 - 100 Die Popularität des Tracks, basiert haupstächlich auf Gesamtzahl der Wiedergaben

Datenqualität

Für diese Datenanalyse wurde in Python 3.14 mit den pandas, matplotlib und plotly Paketen durchgeführt. Mehr Details können auf dem Repository im pyproject.toml gefunden werden.

Übersicht

Anzahl Spalten 25
Anzahl Zeilen 1’204’025
Anzahl leerer Zellen 0
Anteil (%) leerer Zellen 0%
Anzahl duplizierter Zeilen 0
Anteil (%) duplizierter Zeilen 0%

Leere Zellen:

Da die Strings im CSV nicht wrapped sind mit Anführungszeichen, hat Pandas die Datei am Anfang der Analyse falsch geladen. Denn es gibt ein Album vom Künstler Gupi mit dem Namen “None”. Pandas hat dies standardmässig als ein fehlenden Wert interpretiert. Um dies zu lösen haben wir für die Analyse und das Weiterverarbeiten von nun an die Datei folgendermassen geladen:

df = pd.read_csv(Path("../data/full_tracks_features.csv"), keep_default_na=False, na_values=[""])

Duplizierte Zeilen

Überaschenderweise gibt es keine duplizierte Zeilen, obwohl (wie die Dokumentation hier erwähnt) die id Spalte einen Track nicht eindeutig identifiziert.

Verteilung popularity

Die Verteilung der Popularität ist extrem rechtsschief. Wie die folgende Grafik zeigt, hat ein massiver Anteil der Tracks im Datensatz eine Popularität von 0. Da die Anzahl der Tracks mit Popularität 0 die restlichen Werte bei weitem übersteigt, wird hier eine logarithmische Skala für die y-Achse verwendet.

Diese grosse Menge an Tracks mit dem Wert 0 deutet auf viele inaktive, sehr alte oder extrem nischenhafte Songs hin, die auf der Plattform kaum oder gar nicht gehört werden. Für die spätere Modellierung ist dies ein entscheidender Faktor, da wir entscheiden müssen, ob wir diese “toten” Datenpunkte behalten oder filtern wollen.

Code
from pathlib import Path

import pandas as pd
import plotly.express as px
import plotly.io as pio

pio.renderers.default = "plotly_mimetype+notebook_connected"

df = pd.read_csv(Path("../data/full_tracks_features.csv"), keep_default_na=False, na_values=[""])

fig = px.histogram(x=df["popularity"], log_y=True, title="Verteilung der Popularität (log)", labels={"x": "Popularity"})

fig.update_layout(yaxis_title="Anzahl Tracks", xaxis_title="Popularity (0-100)", showlegend=False, yaxis={"dtick": 1})
fig.update_traces(marker_line_color="black", marker_line_width=1)

fig.show()

pop_0_count = (df["popularity"] == 0).sum()
pop_gt_0_count = (df["popularity"] > 0).sum()
print(f"Anzahl Tracks mit popularity 0:     {pop_0_count}")
print(f"Anzahl Tracks mit popularity > 0:   {pop_gt_0_count}")
Anzahl Tracks mit popularity 0:     729167
Anzahl Tracks mit popularity > 0:   474858

Verteilung duration_ms

Die Verteilung der Dauer der Tracks zeigt ebenfalls interessante Muster. Auch hier verwenden wir eine logarithmische Skala für die y-Achse.

Es gibt eine Anzahl an Tracks, die sehr kurz sind (unter 30 Sekunden), konkret 7’709 Stück. Dies sind oft Soundeffekte, Intros oder Interludes. Obwohl dies im Vergleich zur Gesamtmenge ein kleiner Anteil ist, filtern wir diese später heraus, um die Datenqualität zu erhöhen. Zusätzlich zeigt die Tabelle unterhalb der Grafik einige Extremwerte am anderen Ende des Spektrums: Tracks mit einer Dauer von über 90 Minuten.

Code
fig = px.histogram(
    x=df["duration_ms"] / 1000 / 60, log_y=True, title="Verteilung der Dauer (log)", labels={"x": "Dauer (min)"}
)

fig.update_layout(yaxis_title="Anzahl Tracks", xaxis_title="Dauer (min)", showlegend=False, yaxis={"dtick": 1})
fig.update_traces(marker_line_color="black", marker_line_width=1, xbins={"size": 1})

fig.show()

under_30s_count = (df["duration_ms"] <= 30000).sum()
print(f"Anzahl Tracks 30s oder kürzer: {under_30s_count}")

print("Tracks mit Dauer über 90 Minuten:")
long_tracks = df[df["duration_ms"] > 90 * 60 * 1000].copy()
long_tracks["duration_readable"] = pd.to_datetime(long_tracks["duration_ms"], unit="ms").dt.strftime("%H:%M:%S")
display(long_tracks[["name", "artists", "duration_readable", "speechiness"]])
Anzahl Tracks 30s oder kürzer: 7709
Tracks mit Dauer über 90 Minuten:
name artists duration_readable speechiness
4778 Doctorow's Second Law ['Wil Wheaton', 'Cory Doctorow'] 01:34:05 0.9140
4779 Doctorow's Third Law ['Wil Wheaton', 'Cory Doctorow'] 01:40:54 0.8910
11812 Bargrooves Lounge (Continuous Mix 1) ['Various Artists'] 01:32:11 0.0419
149393 Gothic Lolita ['Emilie Autumn'] 01:36:04 0.9210
669375 Los Jefes - Banda Sonora de la Película (feat.... ['Cartel De Santa', 'Draw', 'Millonario', 'Mil... 01:30:40 0.3010
778786 Monstercat Podcast Ep. 086 (Staff Picks 2015) ['Monstercat Call of the Wild'] 01:34:06 0.1210
877968 Bargrooves Deluxe Edition 2018 Mix 1 - Continu... ['Various Artists'] 01:34:39 0.0459
877969 Bargrooves Deluxe Edition 2018 Mix 2 - Continu... ['Various Artists'] 01:41:01 0.0658
885831 Bargrooves Deluxe Edition 2017 - Continuous Mix 2 ['Various Artists'] 01:35:13 0.0557
887120 Arc Angel - Continuous Mix ['Planetary Assault Systems'] 01:32:57 0.0404

Speechiness

Speechiness detektiert das Vorhandensein von gesprochenen Worten in einem Track. Wie die Verteilung zeigt, befinden sich die meisten Tracks im unteren Bereich, was für Musik typisch ist.

Es gibt jedoch einen Anstieg bei sehr hohen Werten. Laut Spotify Dokumentation handelt es sich bei Werten über 0.66 höchstwahrscheinlich um Tracks, die ausschliesslich aus gesprochenen Worten bestehen (z.B. Hörbücher, Poetry Slam, Talkshows). Da unser Fokus auf Musik liegt, werden wir Tracks mit einer Speechiness von über 0.66 aus dem Datensatz entfernen.

Code
fig = px.histogram(
    x=df["speechiness"], log_y=True, title="Verteilung der Speechiness (log)", labels={"x": "Speechiness"}
)

fig.update_layout(yaxis_title="Anzahl Tracks", xaxis_title="Speechiness (0-1)", showlegend=False, yaxis={"dtick": 1})
fig.update_traces(marker_line_color="black", marker_line_width=1, xbins={"start": 0, "end": 1, "size": 0.05})

fig.show()

high_speechiness_count = (df["speechiness"] > 0.66).sum()
print(f"Anzahl Tracks mit Speechiness > 0.66: {high_speechiness_count}")
Anzahl Tracks mit Speechiness > 0.66: 11679

Ausreisser year & release_date

Bei der Analyse der Veröffentlichungsjahre stossen wir auf einige Ausreisser. Zunächst gibt es Tracks, bei denen das Jahr mit 0 angegeben ist. Diese stammen alle von einem einzigen Künstler und scheinen auf Spotify nicht mehr verfügbar zu sein. Daher werden wir diese Einträge aus unserem Datensatz entfernen.

Ein weiterer interessanter Fall sind Tracks mit dem Jahr 1900. Obwohl dies laut Wikipedia falsch ist, sind diese Daten so auf der Spotify hinterlegt. Da wir Spotify als unsere primäre Datenquelle (“Source of Truth”) betrachten, werden wir diese Werte so belassen.

Code
year_0 = df[df["year"] == 0]

print("Tracks mit Jahr 0:")
display(year_0[["name", "artists", "year", "release_date"]])

year_1900 = df[df["year"] == 1900]
print("Beispiele für Tracks mit Jahr 1900:")
display(year_1900[["name", "artists", "year", "release_date"]].head())

df_valid_years = df[df["year"] >= 1900]
fig = px.histogram(
    df_valid_years, x="year", log_y=True, title="Verteilung der Veröffentlichungsjahre (log)", labels={"year": "Jahr"}
)

fig.update_layout(yaxis_title="Anzahl Tracks", yaxis={"dtick": 1})

fig.show()
Tracks mit Jahr 0:
name artists year release_date
815351 Jimmy Neutron ['iCizzle'] 0 0000
815352 I Luv You ['iCizzle'] 0 0000
815353 My Heart ['iCizzle'] 0 0000
815354 I Am (Invincible) ['iCizzle'] 0 0000
815355 Flower Power ['iCizzle'] 0 0000
815356 Heard It Low ['iCizzle'] 0 0000
815357 Hangin On ['iCizzle'] 0 0000
815358 God Loves You ['iCizzle'] 0 0000
815359 You In My Life ['iCizzle'] 0 0000
815360 I Wonder ['iCizzle'] 0 0000
Beispiele für Tracks mit Jahr 1900:
name artists year release_date
450071 Arabian Waltz ['Rabih Abou-Khalil'] 1900 1900-01-01
450072 Dreams Of A Dying City ['Rabih Abou-Khalil'] 1900 1900-01-01
450073 Ornette Never Sleeps ['Rabih Abou-Khalil'] 1900 1900-01-01
450074 Georgina ['Rabih Abou-Khalil'] 1900 1900-01-01
450075 No Visa ['Rabih Abou-Khalil'] 1900 1900-01-01

Korrelationsmatrix

Die Korrelationsmatrix zeigt die Zusammenhänge zwischen den verschiedenen numerischen Features.

Besonders auffällig ist, dass die Zielvariable popularity nur sehr schwache Korrelationen mit den Audio-Features aufweist. Die stärksten Zusammenhänge bestehen zu loudness (0.15) und danceability (0.12), sowie negativ zu acousticness (-0.12) und instrumentalness (-0.12). Dies deutet darauf hin, dass die Popularität eines Songs nicht allein durch einfache Audio-Metriken erklärt werden kann und komplexere Modelle oder zusätzliche Daten (wie Songtexte) notwendig sind.

Hingegen gibt es starke Korrelationen zwischen den Audio-Features selbst, wie zum Beispiel zwischen energy und loudness (0.82) oder energy und acousticness (-0.80).

Code
df_numeric = df.select_dtypes(include="number")
df_numeric = df_numeric.drop(columns=["track_number", "disc_number"], errors="ignore")

corr_matrix = df_numeric.corr().round(2)

fig = px.imshow(
    corr_matrix,
    text_auto=True,
    aspect="auto",
    color_continuous_scale="RdBu_r",
    zmin=-1,
    zmax=1,
    title="Korrelationsmatrix",
)

fig.update_traces(hovertemplate="Feature 1: %{x}<br>Feature 2: %{y}<br>Korrelation: %{z}<extra></extra>")
fig.update_layout(xaxis_title="Features", yaxis_title="Features")

fig.show()

Loudness vs Popularity

Wie in der Korrelationsmatrix ersichtlich, ist loudness eines der wenigen Features, das eine nennenswerte positive Korrelation mit popularity aufweist. Dies spiegelt das Phänomen des “Loudness War” wider, bei dem Musikproduzenten dazu neigen, Tracks lauter zu mastern, um im Radio oder in Playlists mehr Aufmerksamkeit zu erregen und subjektiv “besser” zu klingen.

Einschätzung Datenqualität

Zusammenfassend lässt sich sagen, dass die Datenqualität des Spotify-Datensatzes technisch sehr hoch ist. Es gibt keine fehlenden Werte (nach Korrektur der “None”-Strings) und keine Duplikate. Die Wertebereiche der Audio-Features sind konsistent und gut dokumentiert.

Allerdings zeigt die Analyse der Popularitätsverteilung, dass ein sehr grosser Teil der Daten (Tracks mit Popularität 0) für unsere Zielsetzung – die Vorhersage von Song-Popularität – irrelevant ist. Diese “toten” Datenpunkte würden ein Modell eher verwirren als trainieren, weshalb wir im weiteren Verlauf eine aggressive Filterung vornehmen werden, um uns auf relevante Musik-Tracks zu konzentrieren.

Zudem haben wir festgestellt, dass die Korrelationen zwischen den einfachen Audio-Features und der Popularität generell schwach sind. Dies ist eine wichtige Erkenntnis: Der Erfolg eines Songs lässt sich nicht allein durch Metriken wie Tempo, Tonart oder Tanzbarkeit erklären. Dies bestätigt unsere Hypothese, dass wir komplexere Merkmale benötigen, weshalb wir mit dem nächsten Dataset Songtexte als zusätzliche Datenquelle hinzuziehen.

Details Genius Lyrics

Da wir beim Modellieren bemerkt haben, dass wir mehr Daten für bessere Modellperformance brauchen (Siehe Modellierungsbericht) haben wir uns entschieden, die Songtexte der Tracks anzuschaffen, falls vorhanden.

Um dies zu erreichen haben wir uns für das massive “Genius Song Lyrics” Dataset von CarlosGDCJ auf kaggle.com entschieden. Es enthält eine 9.07 GB grosse CSV Datei, welche Songtexte für über 7 Millionen Songs von der Webseite Genius.com beinhaltet. Auf der einten Seite mussten wir somit nicht selber die Daten scrapen und hatten einen Grossteil der Texte für unser tracks_features.csv dataset. Auf der anderen Seite sind Spotify und Genius verschiedene Dienste, was bedeutet wir haben keine eindeutigen Identifikator um die zwei Datasets zu joinen. Diese Problematik wird später im Kapitel Prozessierte Daten verdeutlicht.

Datenkatalog

Index Name Datentyp Werte Beschreibung
1 title string - Titel des Stücks. Meistens Songs, aber auch Bücher, Gedichte etc.
2 tag string - Genre des Stücks. Meistens “pop”, “rap”, “rock”, “rb”, “country” oder “misc”
3 artist string - Künstler oder Gruppe, dem das Stück zugeschrieben wird
4 year integer Format: YYYY Veröffentlichungsjahr
5 views integer - Anzahl der Seitenaufrufe auf Genius
6 features Liste von strings - Alle Künstler, die beigetragen haben
7 lyrics string - Der Songtext
8 id integer - Genius Identifier
9 language_cld3 string ISO 639-1 Codes Sprache des Songtextes laut CLD3
10 language_ft string ISO 639-1 Codes Sprache des Songtextes laut FastText
11 language string ISO 639-1 Codes Kombinierte Sprache (nur wenn beide Modelle übereinstimmen)

Datenqualität

Für diese Datenanalyse wurde in Python 3.14 mit den pandas, matplotlib und plotly Paketen durchgeführt. Mehr Details können auf dem Repository im pyproject.toml gefunden werden. Diese Datenexploration wurde recht kurz gehalten, da wir nur die Songtexte von diesem Dataset brauchen.

Übersicht

Anzahl Spalten 11
Anzahl Zeilen 5134856
Anzahl leerer Zellen 452394
Anteil (%) leerer Zellen 0.8%
Anzahl duplizierter Zeilen 0
Anteil (%) duplizierter Zeilen 0%

Lyrics

Die Spalte lyrics enthält die Songtexte. Auffällig sind hierbei die Sektions-Tags in eckigen Klammern, wie zum Beispiel [Chorus], [Verse] oder [Intro]. Diese dienen auf Genius der Strukturierung, sind jedoch kein Teil des gesungenen Textes. Um die Qualität der Textanalyse (Embeddings) zu verbessern, werden wir diese Tags im Prozessierungsschritt entfernen.

Code
from pathlib import Path

import pandas as pd

GENIUS_PATH = Path("../data/song_lyrics.csv")
df_genius = pd.read_csv(GENIUS_PATH)

# get first row vertically
first_row = df_genius.head(1)[["title", "features", "lyrics"]].copy()
first_row["lyrics"] = first_row["lyrics"].str[:79]
display(first_row)
title features lyrics
0 Killa Cam {"Cam\\'ron","Opera Steve"} [Chorus: Opera Steve & Cam'ron]\nKilla Cam, Ki...

Unterschiede ‘artists’ & ‘features’

Die artist Spalte im Genius Datensatz weist Formatierungsprobleme auf, bei denen Sonderzeichen (wie Umlaute) oft einfach weggelassen werden. Wie im folgenden Code-Beispiel ersichtlich, wird “Motörhead” zu “Motrhead” und “Blue Öyster Cult” zu “Blue yster Cult”. Interessanterweise enthält die features Spalte, welche eine Liste der beteiligten Künstler beinhaltet, oft die korrekte Schreibweise. Diese Erkenntnis ist entscheidend für das spätere Zusammenführen mit dem Spotify-Datensatz.

Code
from IPython.display import display

song_motorhead = df_genius[df_genius["artist"] == "Motrhead"][["title", "artist", "features"]]
song_byc = df_genius[df_genius["artist"] == "Blue yster Cult"][["title", "artist", "features"]]
display(song_motorhead.head())
display(song_byc.head())
title artist features
50811 Ace of Spades Motrhead {Motörhead}
54677 Orgasmatron Motrhead {Motörhead}
81582 We Are The Road Crew Motrhead {Motörhead}
115348 Fire Fire Motrhead {Motörhead}
121838 Its a Long Way to the Top If You Wanna Rock N ... Motrhead {Motörhead}
title artist features
123021 Dont Fear The Reaper Blue yster Cult {"Blue Öyster Cult"}
140918 Career of Evil Blue yster Cult {"Patti Smith","Blue Öyster Cult"}
198572 Godzilla Blue yster Cult {"Blue Öyster Cult"}
224036 Astronomy Blue yster Cult {"Blue Öyster Cult"}
234153 Burnin’ for You Blue yster Cult {"Blue Öyster Cult"}

Ein weiteres Problem ist die Inkonsistenz bei der Benennung von Künstlern. Wie das Beispiel des Songs “Maneater” zeigt, werden die Künstler im Genius-Datensatz als “Hall & Oates” geführt, während sie im Spotify-Datensatz als “Daryl Hall & John Oates” auftretten. Diese Diskrepanzen erschweren einen direkten Join über den Künstlernamen und bedeuteten dass wir einige Songs nicht matchen werden können.

Code
df_genius_maneater = df_genius[(df_genius["title"] == "Maneater") & (df_genius["year"] == 1982)][
    ["title", "artist", "features", "year"]
]

print("Maneater in Genius dataset:")
display(df_genius_maneater)

df_spotify_maneater = df[(df["name"] == "Maneater") & (df["year"] == 1982)][["name", "artists", "year"]]

print("Maneater in Spotify dataset:")
display(df_spotify_maneater)
Maneater in Genius dataset:
title artist features year
276117 Maneater Hall & Oates {} 1982
Maneater in Spotify dataset:
name artists year
604861 Maneater ['Daryl Hall & John Oates'] 1982

Einschätzung Datenqualität

Obwohl wir primär nur an der lyrics Spalte interessiert sind, sind die Spalten title, artist und features essentiell für das Zusammenführen mit dem Spotify-Datensatz. Wie oben beschrieben, weisen genau diese Spalten Inkonsistenzen und Formatierungsprobleme auf (fehlende Sonderzeichen, abweichende Schreibweisen). Dies erforderte zusätzlichen Aufwand beim Daten-Matching, um eine möglichst hohe Trefferquote zu erzielen und sicherzustellen, dass wir so viele Songtexte wie möglich den korrekten Spotify-Tracks zuordnen können.

Prozessierte Daten

Übersichtstabelle der Prozessierten Daten

Name Input-Datensätze Speicherort
Spotify_Cleaned_No_Noise full_tracks_features.csv data/cleaned_no_noise_tracks_features.csv
Spotify_Genius_No_Noise full_tracks_features.csv, song_lyrics.csv data/spotify_genius_merged_no_noise.csv
Spotify_Genius_No_Noise_Embeddings full_tracks_features.csv, song_lyrics.csv data/spotify_genius_embeddings_v3.parquet

Details Finale Datasets

– give intro to creation of final 2 dataset and intermediate dataset Spotify_Genius_No_Noise

Verworfene Zeilen

Basierend auf den Erkenntnissen aus der Datenexploration haben wir uns entschieden, den Datensatz aggressiv zu bereinigen, um die Modellqualität zu verbessern. Folgende Filterkriterien wurden angewendet:

  1. Popularität > 0: Wie in der Analyse gezeigt, haben sehr viele Tracks eine Popularität von 0. Diese “toten” Tracks sind für die Vorhersage von Hits irrelevant und verzerren das Modell.
  2. Jahr != 0: Tracks mit dem Jahr 0 sind fehlerhafte Datenpunkte, die entfernt werden.
  3. Speechiness < 0.66: Da unser Fokus auf Musik liegt, entfernen wir Tracks, die hauptsächlich aus gesprochenem Wort bestehen (z.B. Hörbücher), definiert durch einen Speechiness-Wert über 0.66.
  4. Dauer > 30 Sekunden: Sehr kurze Tracks (unter 30s) sind oft Intros, Interludes oder Geräuscheffekte und keine vollwertigen Songs.
Code
from pathlib import Path

import pandas as pd

df = pd.read_csv(Path("../data/full_tracks_features.csv"), keep_default_na=False, na_values=[""])

df_clean = df[df["popularity"] > 0].copy()
df_clean = df_clean[df_clean["year"] != 0]
df_clean = df_clean[df_clean["speechiness"] < 0.66]
df_clean = df_clean[df_clean["duration_ms"] > 30000]  # > 30 seconds

Feature Engineering

Um die Daten für maschinelles Lernen nutzbar zu machen, führen wir zwei Transformationen durch:

  1. Track Age: Das Veröffentlichungsdatum (release_date) ist in verschiedenen Formaten vorhanden und als Datum schwer direkt zu modellieren. Wir berechnen stattdessen das Alter des Tracks in Tagen (track_age_days) relativ zu einem Referenzdatum (01.01.2021). Dies gibt dem Modell eine kontinuierliche numerische Grösse für das Alter.
  2. Explicit Flag: Die boolesche Spalte explicit wird in eine Integer-Spalte (0/1) umgewandelt, da viele Modelle numerische Inputs bevorzugen.
Code
df_clean["release_date"] = pd.to_datetime(df_clean["release_date"], format="mixed", errors="coerce")
df_clean["track_age_days"] = (pd.Timestamp("2021-01-01") - df_clean["release_date"]).dt.days
df_clean = df_clean.drop(columns=["release_date"])

df_clean["explicit"] = df_clean["explicit"].astype(int)

output_path = Path("../data/cleaned_no_noise_tracks_features.csv")
df_clean.to_csv(output_path, index=False)

Datasets joinen

Da es keinen gemeinsamen Identifier zwischen Spotify und Genius gibt, erfolgt die Verknüpfung über Künstlername und Songtitel. Um die Trefferquote zu maximieren, normalisieren wir beide Felder (Kleinschreibung, Entfernen von Sonderzeichen).

Wir wenden eine zweistufige Matching-Strategie an:

  1. Primär: Match über die artist Spalte.
  2. Fallback: Match über die features Spalte im Genius-Datensatz, da unsere Analyse zeigte, dass dort oft korrekte Schreibweisen zu finden sind, wenn das artist Feld abweicht.
Code
import ast
import re

import pandas as pd

df_spotify = pd.read_csv("../data/cleaned_no_noise_tracks_features.csv", keep_default_na=False, na_values=[""])
df_genius = pd.read_csv("../data/song_lyrics.csv")


def normalize(s: str) -> str:
    return re.sub(r"[^a-z0-9]", "", str(s).lower())


def get_first_artist(s: str) -> str:
    try:
        # Handle both list ['A'] and set {'A'} string representations
        return ast.literal_eval(str(s).replace("{", "[").replace("}", "]"))[0]
    except:  # noqa: E722
        return ""


# Spotify keys
df_spotify["join_artist"] = df_spotify["artists"].apply(get_first_artist).apply(normalize)
df_spotify["join_title"] = df_spotify["name"].apply(normalize)

# Genius keys
df_genius["join_title"] = df_genius["title"].apply(normalize)

# 1. Use 'artist' column
lookup1 = df_genius.assign(join_artist=df_genius["artist"].apply(normalize))
# 2. Use 'features' column as fallback
lookup2 = df_genius.assign(join_artist=df_genius["features"].apply(get_first_artist).apply(normalize))

# Combine lookups
lookup = pd.concat([lookup1, lookup2])[["join_artist", "join_title", "lyrics"]]
lookup = lookup[lookup["join_artist"] != ""].drop_duplicates(subset=["join_artist", "join_title"])

# Merge
print("Merging datasets...")
df_merged = df_spotify.merge(lookup, on=["join_artist", "join_title"], how="left")
print(f"Match rate: {df_merged['lyrics'].notna().mean():.1%}")

df_merged.drop(columns=["join_artist", "join_title"]).to_csv("../data/spotify_genius_merged_no_noise.csv", index=False)
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
<unknown>:1: SyntaxWarning: invalid decimal literal
Merging datasets...
Match rate: 34.0%

Songtext Verarbeitung

Um die Songtexte als Features zu nutzen, wandeln wir sie in numerische Vektoren (Embeddings) um. Wir verwenden hierfür das Modell jina-embeddings-v3 (Link).

Dabei handelt es sich um ein leistungsstarkes, mehrsprachiges Embedding-Modell mit 570 Millionen Parametern und einer Kontextlänge von 8192 Token, was es ideal für die Verarbeitung ganzer Songtexte macht. Ein Schlüsselfeature ist das sogenannte Matryoshka Representation Learning. Dies ermöglicht es, die Dimension der Ausgabevektoren flexibel zu reduzieren, ohne die semantische Qualität signifikant zu beeinträchtigen. Wir nutzen dies, um die Embeddings von den ursprünglichen 1024 auf kompakte 128 Dimensionen zu kürzen. Dies spart massiv Speicherplatz und beschleunigt das Training der nachfolgenden Regressionsmodelle.

Aufgrund der Grösse des Datensatzes war dieser Schritt sehr rechenintensiv und wurde auf einer Azure Compute Node mit einer NVIDIA A100 GPU durchgeführt.

Code
from pathlib import Path

import pandas as pd
import torch
from sentence_transformers import SentenceTransformer

DATASET = Path("../data/spotify_genius_merged_no_noise.csv")
TARGET_DIM = 128

model = SentenceTransformer(
    "jinaai/jina-embeddings-v3", trust_remote_code=True, model_kwargs={"torch_dtype": torch.float16}
)
model.to("cuda")

df = pd.read_csv(DATASET, keep_default_na=False, na_values=[""])


df["lyrics"] = df["lyrics"].fillna("")
df["lyrics"] = df["lyrics"].astype(str)

print("Starting encoding...")
embeddings = model.encode(
    df["lyrics"].tolist(), task="text-matching", truncate_dim=TARGET_DIM, batch_size=32, show_progress_bar=True
)

df["embedding"] = list(embeddings)

output_path = Path("data/spotify_genius_embeddings_V3.parquet")
df.to_parquet(output_path, index=False)

Datenkatalog

Index Name Datentyp Werte Beschreibung
1 id string Format: base-62 Die Spotify-ID für den Track
2 name string - Der Name des Tracks
3 album string - Das Album, auf dem der Track erscheint.
4 album_id string Format: base-62 Die Spotify-ID für das Album
5 artists Liste von strings - Die Künstler, die den Track performt haben.
6 artist_id Liste von strings Format: base-62 Die Spotify-ID für die Künstler
7 track_number integer Wertebereich: 1 - 50 Die Nummer des Tracks auf dem Album.
8 disc_number integer Wertebereich: 1 - 13 Die Disc-Nummer, auf dem der Track erscheint
9 explicit integer 1 = Ja
0 = Nein oder unbekannt
Ob der Track explizite Texte enthält
10 danceability float Wertebereich: 0 - 1 Tanzbarkeit beschreibt, wie geeignet ein Track zum Tanzen ist, basierend auf einer Kombination musikalischer Elemente.
0.0 → am wenigsten tanzbar, 1.0 → am tanzbarsten
11 energy float Wertebereich: 0 - 1 Wahrnehmungsmass für Intensität und Aktivität dar, typischerweise fühlen sich energiegeladene Tracks schnell, laut und geräuschvoll an
12 key integer Wertebereich: -1 - 11 Die Tonart, in der sich der Track befindet, basierend auf Standard-Pitch-Class-Notation, Wert -1 = keine Tonart erkannt
13 loudness float Wertebereich: -60 - 0
Einheit: Dezibel (dB)
Die Gesamtlautstärke eines Tracks in Dezibel (dB)
14 mode integer 1 = Major, 0 = Minor Gibt die Tonalität (Dur oder Moll) eines Tracks an
15 speechiness float Wertebereich: 0 - 1 Erkennt das Vorhandensein von gesprochenen Worten in einem Track
16 acousticness float Wertebereich: 0 - 1 Konfidenzmass ob der Track akustisch ist
17 instrumentalness float Wertebereich: 0 - 1 Sagt voraus, ob ein Track keinen Gesang enthält
18 liveness float Wertebereich: 0 - 1 Erkennt die Anwesenheit eines Publikums in der Aufnahme
19 valence float Wertebereich: 0 - 1 Beschreibt die musikalische Positivität, die von einem Track vermittelt wird
20 tempo float Einheit: beats per minute (BPM) Das geschätzte Gesamttempo eines Tracks in Schlägen pro Minute (BPM)
21 duration_ms float Einheit: Milisekunden (ms) Die Dauer des Tracks in Millisekunden.
22 time_signature integer Wertebereich: 3 - 7 Eine geschätzte Taktart, gibt wie viele Schläge in jedem Takt enthalten sind
23 year integer Format: YYYY Das Jahr des Veröffentlichungsdatums des Tracks
24 popularity integer Wertebereich: 0 - 100 Die Popularität des Tracks, basiert haupstächlich auf Gesamtzahl der Wiedergaben
25 lyrics string - Der Songtext ohne Sektiontags
26 embedding Liste von floats Dimension: 128 Semantische Vektor-Repräsentation des Songtextes (Jina V3)
27 track_age_days integer Einheit: Tage Alter des Tracks in Tagen relativ zum 01.01.2021

Datenqualität

Für diese Datenanalyse wurde in Python 3.14 mit den pandas, matplotlib und plotly Paketen durchgeführt. Mehr Details können auf dem Repository im pyproject.toml gefunden werden.

Übersicht

Anzahl Spalten 27
Anzahl Zeilen 471’478
Anzahl leerer Zellen 0
Anteil (%) leerer Zellen 0%
Anzahl duplizierter Zeilen 0
Anteil (%) duplizierter Zeilen 0%

Dataset Reduktion

Durch die Anwendung der oben definierten Filter (Popularität > 0, Speechiness < 0.66, Dauer > 30s, etc.) reduzierte sich die Anzahl der Beobachtungen von ursprünglich über 1.2 Millionen auf 471’478. Dies entspricht einer Reduktion von rund 61%. Wir betrachten diesen Verlust als notwendige Qualitätssteigerung, da wir so Rauschen (“Noise”) aus dem Datensatz entfernen und uns auf relevante Musikstücke konzentrieren.

Songtexte

Nach dem Merge-Prozess konnten für 34% der Tracks im bereinigten Datensatz Songtexte gefunden werden. Wir haben diverse Stichproben durchgeführt und sind mit der Qualität der Zuordnung zufrieden. Tracks ohne gefundene Lyrics erhalten einen leeren String als Wert, was vom Embedding-Modell entsprechend verarbeitet wird.

Verteilung popularity nach Bereinigung

Durch das Entfernen der inaktiven Tracks (Popularität = 0) hat sich die Verteilung der Zielvariable stark verändert. Während im Rohdatensatz die Null-Werte dominierten, sehen wir nun eine Verteilung, die sich eher für Regressionsaufgaben eignet. Es ist zwar immer noch eine Rechtsschiefe erkennbar (es gibt weniger Super-Hits als moderate Songs), aber die “Wand” bei 0 ist verschwunden.

Code
import pandas as pd
import plotly.express as px

# Load the merged dataset (without embeddings for speed in this viz)
df_final = pd.read_csv("../data/spotify_genius_merged_no_noise.csv", keep_default_na=False, na_values=[""])

fig = px.histogram(
    df_final,
    x="popularity",
    log_y=True,
    title="Verteilung der Popularität (Bereinigter Datensatz)",
    labels={"popularity": "Popularity"},
    nbins=100,
)

fig.update_layout(yaxis_title="Anzahl Tracks", xaxis_title="Popularity (1-100)", showlegend=False, bargap=0.1)

fig.show()
Unable to display output for mime type(s): application/vnd.plotly.v1+json

Analyse der Lyrics-Abdeckung

Da wir nur für ca. 34% der Tracks Songtexte finden konnten, prüfen wir hier auf systematischen Bias.

Die Grafik zeigt deutlich: Tracks mit Lyrics sind im Durchschnitt fast doppelt so populär (17.8 vs 9.4). Dies ist ein erwarteter und positiver Bias, da auf der Community-Plattform Genius primär Texte für relevante, populäre Songs gepflegt werden. Somit ist die Datenqualität genau dort am höchsten, wo es für unsere Vorhersage am wichtigsten ist.

Code
df_final["has_lyrics"] = df_final["lyrics"].str.len() > 0
df_final["status"] = df_final["has_lyrics"].map({True: "Mit Lyrics", False: "Ohne Lyrics"})

fig = px.box(
    df_final,
    x="status",
    y="popularity",
    color="status",
    title="Popularitäts-Verteilung: Mit vs. Ohne Lyrics",
    points=False,
)

fig.update_layout(xaxis_title="Status", yaxis_title="Popularity", showlegend=False)

fig.show()

# Calculate mean popularity
mean_pop_with = df_final[df_final["has_lyrics"]]["popularity"].mean()
mean_pop_without = df_final[~df_final["has_lyrics"]]["popularity"].mean()

print(f"Durchschnittliche Popularität mit Lyrics: {mean_pop_with:.2f}")
print(f"Durchschnittliche Popularität ohne Lyrics: {mean_pop_without:.2f}")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
Durchschnittliche Popularität mit Lyrics: 17.83
Durchschnittliche Popularität ohne Lyrics: 9.35

Einschätzung Datenqualität

Zusammenfassend verfügen wir nun über einen hochwertigen, bereinigten Datensatz, der spezifisch auf die Vorhersage von Musik-Popularität zugeschnitten ist. Durch die aggressive Filterung von Rauschen (inaktive Tracks, Soundeffekte) und die Anreicherung mit semantischen Songtext-Embeddings haben wir die Informationsdichte für relevante Tracks maximiert ohne externe Faktoren zu den Liedern herzunehmen.